"""Module containing functions relevant to the process of simulating the
application of adaptive tests. Most of this module is based on the work of
[Bar10]_."""

import time
from abc import ABCMeta, abstractmethod

import numpy

from . import cat, irt


class Simulable(metaclass=ABCMeta):
    """Base class representing one of the Simulator components that will receive a reference back to it."""

    def __init__(self):
        super(Simulable).__init__()
        self._simulator = None

    @property
    def simulator(self):
        if self._simulator is not None and type(self._simulator) is not Simulator:
            raise ValueError('simulator has to be of type catsim.simulation.Simulator')
        return self._simulator

    @simulator.setter
    def simulator(self, x: 'Simulator'):
        if type(x) is not Simulator:
            raise ValueError('simulator has to be of type catsim.simulation.Simulator')
        self._simulator = x
        self.preprocess()

    def preprocess(self):
        """Override this method to initialize any static values the `Simulable` might use for the duration of the
        simulation. `preprocess` is called after a value is set for the `simulator` property. If a new value if
        attributed to `simulator`, this method is called again, guaranteeing that internal properties of the
        `Simulable` are re-initialized as necessary."""
        pass


class Initializer(Simulable, metaclass=ABCMeta):
    """Base class for CAT initializers"""

    def __init__(self):
        super().__init__()

    @abstractmethod
    def initialize(self, index: int) -> float:
        """Selects an examinee's initial :math:`\\theta` value

        :param index: the index of the current examinee
        :returns: examinee's initial :math:`\\theta` value
        """
        pass


class Selector(Simulable, metaclass=ABCMeta):
    """Base class representing a CAT item selector."""

    def __init__(self):
        super().__init__()

    @abstractmethod
    def select(self, index: int) -> int:
        """Returns the index of the next item to be administered.

        :param index: the index of the current examinee in the simulator.
        :returns: index of the next item to be applied or `None` if there are no more items to be presented.
        """
        pass


class FiniteSelector(Selector, metaclass=ABCMeta):
    """Base class representing a CAT item selector."""

    def __init__(self, test_size):
        self._test_size = test_size
        self._overlap_rate = None
        super().__init__()

    @property
    def test_size(self) -> int:
        return self._test_size

    @property
    def overlap_rate(self) -> float:
        return self._overlap_rate


class Estimator(Simulable, metaclass=ABCMeta):
    """Base class for proficiency estimators"""

    def __init__(self):
        super().__init__()

    @abstractmethod
    def estimate(self, index: int) -> float:
        """Returns the theta value that minimizes the negative log-likelihood function, given the current state of the
         test for the given examinee.

        :param index: index of the current examinee in the simulator
        :returns: the current :math:`\\hat\\theta`
        """
        pass


class Stopper(Simulable, metaclass=ABCMeta):
    """Base class for CAT stop criterion"""

    def __init__(self):
        super().__init__()

    @abstractmethod
    def stop(self, index: int) -> bool:
        """Checks whether the test reached its stopping criterion for the given user

        :param index: the index of the current examinee
        :returns: `True` if the test met its stopping criterion, else `False`"""
        pass


class Simulator:
    """Class representing the simulator. It gathers several objects that describe the full
    simulation process and simulates one or more computerized adaptive tests

    :param items: a matrix containing item parameters
    """

    def __init__(
            self,
            items: numpy.ndarray,
            true_theta,
            init_theta: float = 0.5,
            selector: Selector = None,
            estimator: Estimator = None,
            stopper: Stopper = None
    ):
        irt.validate_item_bank(items)

        # adds a column for each item's exposure rate
        if items.shape[1] < 5:
            items = numpy.append(items, numpy.zeros([items.shape[0], 1]), axis=1)

        self.true_theta = true_theta

        self.duration = 0
        self.items = items

        self.bias = None
        self.mse = None
        self.rmse = None
        self.overlap_rate = None

        self._init_theta = init_theta
        self.selector = selector
        self.estimator = estimator
        self.stopper = stopper
        for s in [self.selector, self.estimator, self.stopper]:
            s.simulator = self

        self.estimations = []
        self.administered_items = [[]]
        self.response_vectors = [[]]

    @property
    def latest_estimations(self) -> list:
        """Final estimated :math:`\\hat\\theta` values for all examinee."""
        return [self.estimations[-1] if self.estimations else None]

    def simulate(self):
        start_time = time.time()

        est_theta = self._init_theta
        self.estimations.append(est_theta)

        while not self.stopper.stop(0):
            selected_item = self.selector.select(0)

            # if the selector returns None, it means the selector and not the stopper, is asking the test to stop
            # this happens e.g. if the item bank or or the available strata end before the minimum error is achieved
            if selected_item is None:
                break

            # simulates the examinee's response via the four-parameter
            # logistic function
            # response = irt.icc(
            #     self.true_theta, self.items[selected_item][0], self.items[selected_item][1],
            #     self.items[selected_item][2], self.items[selected_item][3]
            # ) >= numpy.random.uniform()

            from random import random
            _b = self.items[selected_item][1]
            if self.true_theta >= _b:  # 小于或等于他能力认为他都能做对
                response = True
            else:
                response = random() > 0.75  # 高于其能力,由25%蒙对的概率

            self.response_vectors[0].append(response)

            # adds the item selected by the selector to the pool of administered items
            self.administered_items[0].append(selected_item)

            # estimate the new theta using the given estimator
            est_theta = self.estimator.estimate(0)

            # count occurrences of this item in all tests
            item_occurrences = numpy.sum(
                [
                    selected_item in administered_list
                    for administered_list in self.administered_items
                ]
            )

            # update the exposure value for this item
            # r = number of tests item has been used on / total number of tests
            self.items[selected_item, 4] = item_occurrences

            self.estimations.append(est_theta)

        self._duration = (time.time() - start_time)

        self._bias = cat.bias([self.true_theta], self.latest_estimations)
        self._mse = cat.mse([self.true_theta], self.latest_estimations)
        self._rmse = cat.rmse([self.true_theta], self.latest_estimations)

        # overlap is calculated only if all examinees answered the same amount of items
        # maybe there is a way to calculate it with tests of different lengths,
        # but I did not find it in the literature
        test_size = None
        len_first = len(self.administered_items[0]) if self.administered_items else None
        if type(self.selector) is FiniteSelector:
            test_size = self.selector.test_size
        elif all(len(i) == len_first for i in self.administered_items):
            test_size = len_first
        if test_size is not None:
            self._overlap_rate = cat.overlap_rate(self.items[:, 4], test_size)

        return self


if __name__ == '__main__':
    import doctest

    doctest.testmod()
